spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { t, tOpt } from '@/i18n';5import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';6import { apiAnalytics } from '@/lib/api-analytics';7import { apiExplore } from '@/lib/api-explore';8import { fixed, formatValue, grouped } from '@/lib/format';9import { seoTitle } from '@/lib/seo';10import { routes } from '@/lib/site';11import type { IndicatorCard } from '@/lib/types';12import type { RegionResponse } from '@/lib/types-explore';13import { NotBuiltState } from '@/components/data/empty-state';14import { Section } from '@/components/data/section';15import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';16import { GroupComparePicker } from '@/components/regions/group-compare-picker';17import { GroupHistory } from '@/components/regions/group-history';18import { MemberMap } from '@/components/regions/member-map';19import { MemberRanking } from '@/components/regions/member-ranking';20import { MembersTable } from '@/components/regions/members-table';2122export const revalidate = 900;2324type Params = { slug: string };2526async function load(slug: string): Promise<RegionResponse | 'not-built' | null> {27 try {28 return await apiExplore.region(slug);29 } catch (e) {30 if (isNotFound(e)) return null;31 if (isNotBuilt(e)) return 'not-built';32 throw e;33 }34}3536export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {37 const { slug } = await params;38 const data = await load(slug);39 if (!data || data === 'not-built') return { title: t('region.notFound'), robots: { index: false } };40 const name = data.group.name ?? slug;41 const title = seoTitle.region(name);42 const description = t('region.description', { name, n: data.n_members });43 const canonical = routes.region(data.group.slug ?? slug);44 return { title: { absolute: `${title} | ${t('site.name')}` }, description, alternates: { canonical }, openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, twitter: { card: 'summary_large_image', title, description } };45}4647/** Extra indicators offered in the member ranking picker besides the headline ones. */48const EXTRA_RANKING = ['gdp-per-capita-ppp', 'population-growth', 'fertility-rate', 'renewable-electricity-share', 'internet-users', 'co2-per-capita', 'general-government-gross-debt-pct-gdp', 'median-age'];4950export default async function RegionPage({ params }: { params: Promise<Params> }) {51 const { slug } = await params;52 const data = await load(slug);53 if (data === null) notFound();54 if (data === 'not-built') return <NotBuiltState />;5556 const g = data.group;57 const name = g.name ?? slug;58 const gslug = g.slug ?? g.id;59 const kindLabel = tOpt(`regions.kindLabel.${g.kind ?? 'custom'}`, g.kind ?? '');60 const isWorld = g.id === 'world';61 const [countriesRes, extraInd, vsWorld, regions] = await Promise.all([safe(api.countries()), safe(apiExplore.indicators({ featured: true })), isWorld ? Promise.resolve(null) : safe(apiAnalytics.regionsCompare(gslug, 'world')), safe(apiExplore.regions())]);62 const shares = vsWorld?.shares[g.id] ?? vsWorld?.shares[gslug] ?? null;63 const otherDefault = gslug === 'g7' ? 'brics' : 'g7';64 const countries = countriesRes?.items ?? [];65 const memberIds = data.members.map((m) => m.id);66 const aggs = Object.values(data.aggregates);67 const options: IndicatorCard[] = [...data.headline_indicators, ...(extraInd?.items ?? []).filter((i) => EXTRA_RANKING.includes(i.slug) || i.featured)].filter((i) => i.ranking_eligible !== false);68 const bySize = [...data.members].sort((a, b) => (b.values.population?.value ?? 0) - (a.values.population?.value ?? 0));69 const compareSlugs = bySize.slice(0, 8).map((m) => m.slug ?? m.id);70 const compareAll = data.n_members <= 8;7172 return (73 <>74 <PageHeader75 crumbs={[{ href: routes.regions(), label: t('regions.title') }]}76 eyebrow={kindLabel}77 title={name}78 lede={data.description}79 meta={`${t('regions.members', { n: grouped(data.n_members) })}${g.wb_code ? ` · ${g.wb_code}` : ''}`}80 actions={81 compareSlugs.length >= 2 ? (82 <Link href={routes.compare(...compareSlugs)} className={ACTION_CLS}>83 {compareAll ? t('region.compareAll') : t('region.compareFirst', { n: compareSlugs.length })}84 </Link>85 ) : null86 }87 />8889 <Section id="aggregates" title={t('region.aggregates.title')} subtitle={t('region.aggregates.sub')} className="border-t-0">90 {shares ? (91 <dl className="mb-4 grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-4">92 {(['gdp_share_pct', 'population_share_pct'] as const).map((k) => {93 const v = shares[k];94 return (95 <div key={k} className="border-t border-rule py-3">96 <dt className="eyebrow">{k === 'gdp_share_pct' ? t('regions.compare.gdpShare') : t('regions.compare.popShare')}</dt>97 <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink">{v != null ? `${fixed(v, 1)} %` : t('common.na')}</dd>98 <dd className="mt-1.5 h-1.5 w-full max-w-[12rem] overflow-hidden rounded-xs bg-surface-2" aria-hidden>99 <span className="block h-full bg-accent" style={{ width: `${Math.min(100, v ?? 0)}%` }} />100 </dd>101 </div>102 );103 })}104 </dl>105 ) : null}106 {aggs.length === 0 ? (107 <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>108 ) : (109 <dl className="grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">110 {aggs.map((a) => (111 <div key={a.indicator.slug} className="border-t border-rule py-3">112 <dt className="text-xs font-medium text-ink-2">113 <Link href={routes.indicator(a.indicator.slug)} className="link-quiet -my-3 inline-flex min-h-[44px] items-center md:my-0 md:min-h-0">114 {a.indicator.short_name ?? a.indicator.name}115 </Link>116 <span className="ml-1.5 rounded-xs border border-rule px-1 text-2xs uppercase tracking-wide text-ink-3">{tOpt(`regions.${a.kind}`, a.kind)}</span>117 </dt>118 <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink">{formatValue(a.value, a.indicator)}</dd>119 <dd className="tnum mt-1 text-xs text-ink-3">{t('region.aggregates.n', { n: grouped(a.n ?? 0), year: a.year ?? '' })}</dd>120 </div>121 ))}122 </dl>123 )}124 </Section>125126 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">127 <Section id="ranking" title={t('region.ranking.title')} subtitle={t('region.ranking.sub')}>128 <MemberRanking groupSlug={gslug} groupName={name} initial={data.ranking} options={options} />129 </Section>130 <Section id="map" title={t('region.map.title')} subtitle={t('region.map.sub', { n: grouped(data.n_members), name })}>131 {countries.length ? <MemberMap members={memberIds} countries={countries} name={name} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}132 </Section>133 </div>134135 {vsWorld && Object.keys(vsWorld.history).length ? (136 <Section id="history" title={t('region.history.title')} subtitle={t('region.history.sub', { name })}>137 <GroupHistory data={vsWorld} ids={[g.id]} names={{ [g.id]: name }} />138 </Section>139 ) : null}140141 <Section id="compare-group" title={t('regions.compare.withOther')} subtitle={t('regions.compare.withOtherSub')} actions={<Link href={routes.regionCompare(gslug, otherDefault)} className="text-accent hover:underline">{t('regions.compare.open')} →</Link>}>142 <GroupComparePicker groups={regions?.items ?? []} a={gslug} b={otherDefault} fixedA />143 </Section>144145 <Section id="members" title={t('region.members.title')} subtitle={t('region.members.sub', { n: grouped(data.n_members) })}>146 {data.members.length ? <MembersTable members={data.members} indicators={data.headline_indicators} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}147 </Section>148 </>149 );150}151